Skip to content

Linux 多线程避坑:pthread_join 与 pthread_detach 二选一 ​

当我们创建一个线程的时候,线程其实是有两种状态的。

  • 一种是可连接状态,也叫 joinable,默认创建的线程就是这种状态。可连接状态线程,需要主线程调用 pthread_join 阻塞等待子线程退出,回收子线程的线程控制块信息,不然会引发资源泄露。

  • 还有一种是分离线程状态 detached,是通过 pthread_detach 将线程设置为分离状态。分离状态的线程退出后,由内核自动回收线程控制块信息。主线程不需要调用 pthread_join 阻塞等待子线程退出。

我们分别看下这两种线程,在使用时需要会遇到哪些问题, 以及如何避免

一、可连接状态线程,pthread_join ​

在创建完线程后,默认的线程状态就是可连接状态 joinable。

线程创建完成后,主线程要调用 pthread_join,此时主线程会阻塞等待,直到子线程退出,完成子线程的资源回收工作。

一旦子线程退出后,pthread_join 能够获取到子线程退出的返回码。

这也是生产环境下,高并发服务器使用得最多的线程。例如多线程高并发服务器,主线程会创建网络 IO 线程以及业务线程。网络 IO 线程负责收发包、业务线程处理业务逻辑。主线程使用 pthread_join,阻塞等待网络 IO 线程和业务线程退出。

因此,大家在选型上还是使用尽量这种线程,分离状态线程 detached 很少使用。

pthread_join 本质上是给可连接线程做收尾的函数。它不是用来创建线程,也不是用来杀线程,而是用来:

  1. 阻塞等待某个线程结束
  2. 取得这个线程的退出值
  3. 回收这个线程结束后仍保留的资源, 也就是线程控制块

来看一个简单示例,主线程创建子线程,并等待子线程结束。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

void *thread_function(void *arg)
{
    printf("Sub thread started");
    sleep(60);
    return NULL;
}

int main(void)
{
    pthread_t thread_id;
    pthread_create(&thread_id, NULL, thread_function, NULL);

    void * tval = NULL;
    pthread_join(thread_id, &tval);    
    
    printf("sub thread exiting, ret:%d\n", (int)tval);
    return 0;
}

在这个示例中,主线程会在 pthread_join 调用中阻塞等待 60 s,直到子线程退出。

子线程退出的返回值是 NULL,也就是 0,因为会打印出:

sub thread exiting, ret:0

1、如果子线程退出时,没有返回值。主线程调用 pthread_join 获取到的值是多少呢? ​

子线程退出时的返回码,也就是子线程处理函数的返回值。

子线程退出时,函数末尾既不 return,也不调用 pthread_exit,那 pthread_join 获取到的返回值是未定义的,不能依赖它。

void *thread_function(void *arg)
{
    printf("Sub thread started");
    //无 return NULL;
    //无 pthread_exit(NULL)
}

然后主线程调用

int main()
{
    ......
    void * tval = NULL;
    pthread_join(thread_id, &tval); 
}

这个时候获取到的值是未定义的,有可能是 0,有可能是随机值,取决于 eax 寄存器当时的值是多少。

原因是:你的线程处理函数返回类型是:void *

也就是说,调用约定要求它返回一个指针值。线程函数正常执行到末尾时,相当于函数返回了。但你没有给返回值,此时存放返回值的 eax 寄存器里是什么内容就不确定了。

因此,线程退出时,正常的规范是要给一个返回值,而不能偷懒。

2、如果主线程不调用 pthread_join,那子线程结束后,会有什么问题? ​

如果主线程不调用 pthread_join,那子线程结束后,子线程会变成一个僵尸线程。类似子进程退出,父进程不 wait,子进程会变成一个僵尸进程。

子线程虽然退出了,但还有一些资源没完全释放。

每个线程都有一个线程控制块,线程退出时,线程控制块并没有释放。

线程控制块里存放了以下信息。

  • 线程 ID。
  • 线程是否还活着。
  • 线程是否为可连接状态 joinable。
  • 线程是否已经被 join。
  • 线程退出码。

另外,每个线程都有一个栈空间,线程退出时栈并不一定释放,取决于 Linux/glibc 版本实现。也可能导致栈内存泄露。

如果主线程不 pthread_join,则子线程退出时,将导致线程控制块没有回收,以及栈空间未释放。最终将

  • 引发内存泄露。
  • 可创建线程数量减少,资源耗尽,可能无法创建出线程。

之所以线程控制块不释放,是为了让主线程 pthread_join 能够获取到线程的退出状态。如果线程退出时就将线程控制块给释放了,那主线程再也无法获取到子线程状态。

3、如果不 pthread_join,主线程退出后,子线程会是什么行为。 ​

主线程如果是通过 return 返回,或者调用 exit 返回,则整个进程就退出了。操作系统会回收整个进程的资源,包括所有线程、内存、文件描述符等。

int main()
{
    //exit(0);
    //return 0;
}

子线程也属于进程的一部分,进程都退出了,子线程也会立即跟着退出。

主线程如果是通过 pthread_exit 退出的,退出的只是主线程,整个进程并没有退出。此时子线程依然是会运行的

int main()
{
    pthread_create(&thread_id, NULL, thread_function, NULL);
    pthread_exit(NULL);
}

这么写,主线程退出后,子线程还在运行。

但会有个问题,后续子线程也运行结束退出了,线程控制块也不会释放,变成僵尸线程。因为父线程已经退出了,没有人调用 pthread_join 回收线程控制块

正确的做法是:

int main()
{
    pthread_create(&thread_id, NULL, thread_function, (void *)&param);
    pthread_detach(thread_id);
    pthread_exit(NULL);
}

此时,子线程变成分离状态(detached)。分离状态的子线程终止后自动释放资源,无需其他线程干预。由内核释放线程控制块(TCB)

因此,我们在使用上我们要注意:

  • 需要等待线程结束、获取线程退出的返回值:用 pthread_join
  • 不关心线程结果,让它自己跑完自己释放:用 pthread_detach

根据需要两者 2 选 1,不要什么都不用

二、分离状态 detached ​

把一个线程设置为“分离状态”,使该线程结束后,由系统自动回收其线程资源,不再需要、也不允许其他线程调用 pthread_join() 回收它。它解决的是“线程退出后的资源回收方式”

分离线程,主要用于一些不关注线程退出状态的场景。

  • 后台任务(写日志记录、监控、定时任务)
  • 主线程不需要获取线程返回值的场景

在生产环境下,使用场景是非常少的。但既然 Linux 操作系统提供了这么个功能,还是要了解下,增加些知识面。

有两种方式将线程设置为分离状态。

  • 创建子线程的时候,通过设置线程属性,调用 pthread_attr_setdetachstate 将线程设置为分离状态。这是最推荐的做法。
  • 子线程运行后,通过 pthread_detach 将子线程设置为分离状态。

1、创建子线程的时候,通过 pthread_attr_setdetachstate 将线程设置为分离状态 ​

在创建子线程的设置,可以通过设置线程属性,将线程状态设置为分离状态。

子线程一运行起来,就变成分离状态。

Linux 操作系统提供了两个接口,用于设置、获取线程分离状态。

API 接口功能
pthread_attr_setdetachstate将线程设置为分离状态,或者可连接状态
pthread_attr_getdetachstate获取线程的状态
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>

void *thread_func(void *arg)
{   
    sleep(1);
    printf("sub thread running\n");
    return NULL;
}

int main(void)
{
    pthread_t thread_id;
    pthread_attr_t attr;
    int ret;

    // 设置线程属性,将线程状态设置为分离
    ret = pthread_attr_init(&attr);
    ret = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

    // 获取是否已经为分离状态
    int detachstate;
    ret = pthread_attr_getdetachstate(&attr, &detachstate);
    if (detachstate == PTHREAD_CREATE_DETACHED)
    {
        printf("sub thread state: DETACHED\n");
    }
  
    ret = pthread_create(&thread_id, &attr, thread_func, NULL);
    pthread_attr_destroy(&attr);
    
    // 此时获取线程退出结果,必然失败
    ret = pthread_join(thread_id, NULL);
    if (ret != 0)
    {
        printf("pthread_join failed: %s, ret:%d\n", strerror(ret), ret);
    }

    //主线程运行时间必须比子线程长,不然进程退出,子线程也退出了
    sleep(10);
   
    return 0;
}

对于分离线程,主线程是无法获取分离线程的返回值。返回值被存储在 TCB(Thread Control Block)中,分离线程退出时,由内核释放 TCB,主线程无法获取到线程的退出状态。

因此,一旦子线程变为分离状态,主线程就不能调用 pthread_join(),否则返回 EINVAL。

另外,即使是分离状态,如果主线程使用的是 return,或者 exit 退出,则主线程退出意味着整个进程退出了。此时分离状态的子线程也会退出。

因此分离线程正确使用方式是:

  • 主线程生命周期长于分离线程
  • 主线程退出时,使用 pthread_exit,而不是 return,或者 exit

2、子线程运行后,通过 pthread_detach 将子线程设置为分离状态。 ​

子线程运行后,主线程调用 pthread_detach 将子线程设置为分离状态。只是将子线程设置为分离状态,不会阻塞等待子线程结束。pthread_detach 调用后立即就会返回。

来看一个示例,在这个示例中,主线程创建完线程后,设置子线程为分离状态。子线程退出后,内核会自动回收线程的控制块信息。

#include <stdio.h>
#include <string.h>

static void *worker(void *arg)
{
    sleep(60);
    printf("worker running\n");
    return NULL;
}

int main(void)
{
    pthread_t tid;
    int ret = pthread_create(&tid, NULL, worker, NULL);
    
    //线程创建完成后,设置为分离状态
    pthread_detach(tid);
	
    // join已经分离的线程会失败,
    int ret = pthread_join(tid, NULL);
    if (ret != 0)
    {
        printf("pthread_join failed: %s, ret:%d\n", strerror(ret), ret);
    }
	
    //不能用return和exit,会导致整个进程退出。
    //分离的线程也会被退出
    pthread_exit(NULL);
}

这个示例中,哪怕父线程退出了,子线程也会继续运行,直到 60 s 后退出。子线程退出时,由内核自动回收线程控制块。

主线程调用,还是子线程调用 pthread_detach ​

主线程创建子线程,要将子线程设置为分离状态。是主线程调用,还是子线程自己调用 pthread_detach?

两种方式都可以。pthread_detach() 不要求必须由主线程或目标线程自己调用;任何线程都可以把一个有效的可连接线程 joinable 设置为分离线程状态。

工程上通常推荐:由创建者决定并设置线程的回收方式。因此由主线程调用:最常见。

如果子线程自己将自己设置为分离状态,和主线程的 join 存在并发竞争。

我们看下这个过程:

1、子线程可以通过 pthread_self() 获取自己的线程 ID,然后分离自己

void *worker(void *arg)
{
    int ret = pthread_detach(pthread_self());
    return NULL;
}

2、在子线程执行 pthread_detach(pthread_self()) 之前,主线程可能不知道它是否已经成功 detach。如果主线程同时尝试 join,就会出现竞态:

主线程:pthread_join(tid, ...)

子线程:pthread_detach(pthread_self())

谁先成功不可预测,这属于错误的线程生命周期设计

分离状态的线程,是否还能被取消? ​

答案是: 可以。

分离线程只影响资源回收,内核自动释放线程控制块。并不影响 pthread_cancel():

因此,对于分离线程,也是可以调用 pthread_cancel(tid); 将分离线程取消,让分离线程退出的。

这和可连接线程没有任何区别。

但这里存在生命周期问题:

  1. 分离状态的线程可能已经退出;
  2. 退出后 pthread_t 对应的线程标识可能失效;甚至可能被重新用于另一个新线程。
  3. 此时主线程取消分离线程,行为是未定义的。

因此,最好还是使用 joinable 可连接线程更稳妥。

三、写在最后 ​

UNIX 环境高级编程 这本书介绍了可连接和分离线程两种状态。但似乎有点教材了,比较抽象,对初学者而言没那么容易理解,不知道怎么用。

文章对线程两种状态,在使用过程中会遇到的各种问题进行了展开分析。读者在回头看 UNIX 环境高级编程这本书时,也就不那么抽象了。

本章所有的示例代码,都可以从这里下载:

https://gitee.com/apelifexu/linux-network/tree/master/thread

位于 pthread_join 和 thread_detach 两个目录

我尽量站在读者的角度把来龙去脉讲清楚了,但每个人理解不一样。有任何疑问,可以在评论区里讨论,也可以联系作者。

如何联系作者

  • yudongtc 这个博客每周更新一篇高并发干货,图解方式讲解,需要可以浏览器访问。

  • 关注高并发成长记,加入 linux 网络编程交流群,一起讨论。

基于 VitePress 构建